深入理解js

一次性弄懂执行上下文,作用域链,变量对象,词法作用域,闭包,this的指向问题,箭头函数

Posted by AzirKxs on 2022-10-23
Estimated Reading Time 13 Minutes
Words 3.3k In Total
Viewed Times

前言

参考文章:Issues · mqyqingfeng/Blog (github.com)

什么是变量提升和函数提升? - 知乎 (zhihu.com)

之前写了一篇糟糕的文章JavaScript的工作机制 - AzirKxs的博客 | 分享与记录,自己当时也没弄明白,又经过了一阶段的学习,参考了大佬的文章与一些讲解视频,我应该可以把这部分内容说的很清楚了(概念繁多,我还是挺有野心的)。

本文涉及到的概念:执行上下文,作用域链,变量对象,词法作用域,闭包。

变量与函数提升

虽然伴随着es6中let与const的出现,已经逐渐抛弃了var的用法,但是为了弄明白js的变量提升机制,我们还是要在把var大爷请回来

  • var声明的变量会被提前(注意是声明而不是赋值 )
1
2
console.log(a);
var a = 10;

也就是说以上代码的实际执行顺序是

1
2
3
var a;
console.log(a);
a = 10;
  • 函数也会提前声明,并且函数的声明先于变量的声明
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
console.log(test)
console.log(test())
var test = 10;
function test() {
console.log(a)
var a = 20;
console.log(a)
}
console.log(test)
打印结果如下
// ƒ test()
//undefined
//20
//undefined
//10

也就是说上面代码的实际执行顺序为

1
2
3
4
5
6
7
8
9
10
11
12
function test(){...}
var test
console.log(test) //ƒ test()
test(){
var a;
console.log(a) //undefined
a = 20
console.log(a) //20
}
console.log( undefined ) //undefined 这里会打印test函数执行的返回值,test函数没有写return默认返回undefined
test = 10
console.log(test)//10

至于为什么 下面test声明没有覆盖函数test的声明,那是因为如果变量名称跟已经声明的函数或者函数的形参相同,则变量声明不会干扰已经存在的这类属性。

执行上下文

什么是执行上下文?

JavaScript 引擎并非一行一行地分析和执行程序,而是一段一段地分析执行。当执行一段代码的时候,会进行一个“准备工作”,比如第一个例子中的变量提升,和第二个例子中的函数提升。这里的“一段一段就是指执行上下文”,执行上下文定义了变量或者函数有权或无权访问其他数据,决定了各自的行为。

什么条件下会创建执行上下文?

当js代码遇到“可执行代码”就会创建一个执行上下文

可执行代码:全局代码、函数代码、eval代码。

执行上下文的组成

每个执行上下文,都有三个重要属性:

  • 变量对象(Variable object,VO);
  • 作用域链(Scope chain);
  • this

执行上下文栈

JavaScript 引擎创建了执行上下文栈(Execution context stack,ECS)来管理执行上下文,具有栈数据结构的特征。

在js代码执行的过程中,永远都会有一个全局执行上下文再栈底

分析如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
function fun3() {
console.log('fun3')
}

function fun2() {
fun3();
}

function fun1() {
fun2();
}

fun1();

当执行一个函数的时候,就会创建一个执行上下文,并且压入执行上下文栈,当函数执行完毕的时候,就会将函数的执行上下文从栈中弹出

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 伪代码

// fun1()
ECStack.push(<fun1> functionContext);

// fun1中竟然调用了fun2,还要创建fun2的执行上下文
ECStack.push(<fun2> functionContext);

// 擦,fun2还调用了fun3!
ECStack.push(<fun3> functionContext);

// fun3执行完毕
ECStack.pop();

// fun2执行完毕
ECStack.pop();

// fun1执行完毕
ECStack.pop();

// javascript接着执行下面的代码,但是ECStack底层永远有个globalContext

变量对象

全局上下文的变量对象

在全局上下文中,变量对象就是全局对象window

函数的变量对象

在函数上下文中,用活动对象AO来表示函数的变量对象VO,有两个不同的状态,分为分析和执行两个过程

分析状态下:

  • 函数的所有形参,属性值为undefined

  • 函数内的函数声明,如果变量对象已经存在相同名称的属性,则完全替换这个属性

  • 函数内的变量声明,如果变量名称跟已经声明的形式参数或函数相同,则变量声明不会干扰已经存在的这类属性

1
2
3
4
5
6
7
8
9
10
function foo(a) {
var b = 2;
function c() {}
var d = function() {};

b = 3;

}

foo(1);

这时候的AO是:

1
2
3
4
5
6
7
8
9
10
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: undefined,
c: reference to function c(){},
d: undefined
}

在代码执行阶段,会顺序执行代码,根据代码,修改变量对象的值

此时的AO是:

1
2
3
4
5
6
7
8
9
10
AO = {
arguments: {
0: 1,
length: 1
},
a: 1,
b: 3,
c: reference to function c(){},
d: reference to FunctionExpression "d"
}

综上所述:

  1. 全局上下文的变量对象初始化是全局对象
  2. 函数上下文的变量对象初始化只包括 Arguments 对象
  3. 在进入执行上下文时会给变量对象添加形参、函数声明、变量声明等初始的属性值
  4. 在代码执行阶段,会再次修改变量对象的属性值

作用域链

当查找变量的时候,会先从当前上下文的变量对象中查找,如果没有找到,就会从父级(词法层面上的父级)执行上下文的变量对象中查找,一直找到全局上下文的变量对象,也就是全局对象。这样由多个执行上下文的变量对象构成的链表就叫做作用域链。

分析如下函数的执行上下文

1
2
3
4
5
function foo() {
function bar() {
...
}
}

首先就是全局执行上下文:

1
2
3
4
globalContext={
AO:window,
Scope: global.AO
}

将foo执行上下文中的变量对象与全局对象的执行上下文进行了拼接

1
2
3
4
5
fooContext = {
AO:{...}//变量对象
Scope: [fooContext.AO,globalContext.AO],
this
}

接着

1
2
3
4
5
barContext = {
AO:{}
Scope:[barCOntext.AO,fooContext.AO,globalContext.AO]
this
}

这样形成的Scope就叫做作用域链,在查找变量的时候会顺着作用域链查找

词法作用域

词法作用域也叫静态作用域,与之相对的是动态作用域,js采用的是静态作用域

分析代码:

1
2
3
4
5
6
7
8
9
10
11
12
var value = 1;

function foo() {
console.log(value);
}

function bar() {
var value = 2;
foo();
}

bar();//1

因为 JavaScript 采用的是词法作用域,函数的作用域在函数定义的时候就决定了,,函数是在全局中定义的,因此打印的value值为1。而动态作用域是指函数的作用域是在函数调用的时候才决定的。

我们从执行上下文的角度来分析一下上面代码的执行

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
//js代码执行,将全局执行上下文压栈
ECStack.push(globalContext);

// 调用了bar函数,将bar函数上下文压栈
ECStack.push(<fun1> barContext);

// bar调用了foo,将foo函数上下文压栈
ECStack.push(<fun2> Context);

// foo执行完毕,foo变量对象出栈
ECStack.pop();

// bar执行完毕,bar变量对象出栈
ECStack.pop();

// js执行完毕,全局变量对象出栈
ECStack.pop();

1
2
3
4
5
6
7
8
9
10
11
12
barContext={
AO:{
arguments
value = 2
},
Scope:[barContext.AO,globalContext.AO]
}

fooContext={
AO:{...},
Scope:[fooContext.AO.globalContext.AO]
}

首先js代码执行,进入了全局执行上下文,接着执行bar函数,进入了bar的执行上下文,在执行foo函数的时候,我们进入了foo的执行上下文fooContext中,而fooContext.AO没有value值,因此顺着作用域链找到了全局变量对象globalContext.AO中的value值为1

this

执行上下文的this指向问题涉及到规范层面的一些问题,具体参考JavaScript深入之从ECMAScript规范解读this · Issue #7 · mqyqingfeng/Blog (github.com),这里我记录一下总结的部分:

1.计算 MemberExpression 的结果赋值给 ref

2.判断 ref 是不是一个 Reference 类型()

1
2
3
4
5
2.1 如果 ref 是 Reference,并且 IsPropertyReference(base value) 是 true, 那么 this 的值为 GetBase(base value)

2.2 如果 ref 是 Reference,并且 base value 值是 Environment Record, 那么this的值为 ImplicitThisValue(base value) //始终返回undefined

2.3 如果 ref 不是 Reference,那么 this 的值为 undefined

闭包

什么是闭包?

MDN对于闭包的解释:

闭包是指那些能够访问自由变量的函数。

自由变量是指在函数中使用的,但既不是函数参数也不是函数的局部变量的变量。

闭包 = 函数 + 函数能够访问的自由变量

1
2
3
4
5
6
7
var a = 1;

function foo() {
console.log(a);
}

foo();

foo访问了一个外部的变量,这个变量不是函数参与也不是函数的局部变量,因此这是一个闭包。

在《JavaScript权威指南》中就讲到:从技术的角度讲,所有的JavaScript函数都是闭包。

这与我们平时见到的闭包不一样,当然这只是理论上的闭包,在实践角度上将:

  1. 即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)
  2. 在代码中引用了自由变量

一个经典的问题

分析如下经典的代码:

1
2
3
4
5
6
7
8
9
10
11
var data = [];

for (var i = 0; i < 3; i++) {
data[i] = function () {
console.log(i);
};
}

data[0]();
data[1]();
data[2]();
  • 声明阶段
1
2
var data[]
var i
  • 执行for循环
1
2
3
4
5
6
7
i = 0
data[0] = function(){ console.log(i) }
i = 1
data[1] = function(){ console.log(i) }
i = 2
data[2] = function(){ console.log(i) }
i = 3
  • 执行data[0]
1
2
3
4
5
6
7
8
//1.创建data[0]的执行上下文
data[0]Context={
AO:{arguments},
Scope:{data[0]Context.AO,globalContext.AO}
}
//2.执行函数
console.log(i)//3
//需要打印i,于是顺着作用域链查找i,data[0]Context中没有,因此找到了全局作用域中的i为3

使用闭包

从上面我们可以看到,var顺着作用域链找到了全局变量对象中的的i,我们想让他执行第几个函数就打印几,由于ES6之前还没有块级作用域,只能通过添加函数作用域的办法解决,这时候就需要使用闭包来解决这个问题了

核心思想就是:通过创建一个匿名立即执行函数添加一个新的执行上下文来达到将变量保存的目的

改进版本:

1
2
3
4
5
6
7
8
9
10
11
12
13
var data = [];

for (var i = 0; i < 3; i++) {
data[i] = (function (i) {
return function(){
console.log(i);
}
})(i);
}

data[0]();
data[1]();
data[2]();

让我们来分析一下上述的代码

  • 声明阶段
1
2
var data
var i
  • 执行for循环
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
i = 0
//执行第一次匿名立即执行函数并将结果赋值给data[0]
//anonymous0(我们姑且称之为匿名函数0号)创建上下文
anonymous0Context = {
AO:{
arguments,
i:0,
function(){console.log(i)};
},
Scope:{anonymous0Context.AO,globalContext.AO}
}
data[0] = function(){console.log(i)}
i = 1
/执行第二次匿名立即执行函数并将结果赋值给data[1]
//anonymous1创建上下文
anonymous1Context = {
AO:{
arguments,
i:1,
function(console.log(i));
},
Scope:{anonymous0Context.AO,globalContext.AO}
}
i=2
//...接着执行第三次
i=3
data[0] = function(){console.log(i)}
data[1] = function(){console.log(i)}
data[2] = function(){console.log(i)}
  • 执行data[0]
1
2
3
4
5
6
7
8
//1.创建data[0]的执行上下文,注意:data[0]()这个函数是在匿名函数中声明的,因此:
data[0]Context={
AO:{arguments},
Scope:{data[0]Context.AO,anymous0Context.AO,globalContext.AO}
}
//2.执行函数
console.log(i)//0
//需要打印i,于是顺着作用域链查找i,data[0]Context中没有,因此找到了anymous0Context.AO中的i为3

这下应该差不多明白闭包了,但是还有一个问题,还记得闭包的定义吗?

即使创建它的上下文已经销毁,它仍然存在(比如,内部函数从父函数中返回)

不急,我们从执行上下文作栈的角度再来分析一下上面的代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
//js代码执行,将全局执行上下文压栈
ECStack.push(globalContext);

// 调用了anonymous0函数,将anonymous0函数上下文压栈
ECStack.push(<fun1> anonymous0Context);
// anonymous0执行完毕,anonymous0变量对象出栈
ECStack.pop();

// 调用了anonymous1函数,将anonymous1函数上下文压栈
ECStack.push(<fun1> anonymous1Context);
// anonymous1执行完毕,anonymous1变量对象出栈
ECStack.pop();

// 调用了anonymous2函数,将anonymous2函数上下文压栈
ECStack.push(<fun1> anonymous2Context);
// anonymous2执行完毕,anonymous2变量对象出栈
ECStack.pop();

// 调用了data[0]函数,将data[0]函数上下文压栈
ECStack.push(<fun1> data[0]Context);
...

你可能已经发现问题了,三个匿名函数的执行上下文应该都已经出栈了,为什么变量对象还能存在呢?

那是因为即使 anonymousContext被销毁了,但是 JavaScript 依然会让 anonymousContext.AO 活在内存中,data函数依然可以通过 data函数的作用域链找到它(从内存,垃圾回收机制标记清除,以及浏览器的垃圾回收优化的角度,只是执行上下文出战了,内存中没有被销毁),正是因为 JavaScript 做到了这一点,从而实现了闭包这个概念。

闭包,了然了!


如果这篇文章对你有帮助,可以bilibili关注一波 ~ !此外,如果你觉得本人的文章侵犯了你的著作权,请联系我删除~谢谢!